# -*- coding: utf-8 -*-
#
# Copyright 2009 Vanderbilt University
# 
# Licensed under the Apache License, Version 2.0 (the "License"); 
# you may not use this file except in compliance with the License. 
# You may obtain a copy of the License at 
# 
#     http://www.apache.org/licenses/LICENSE-2.0 
# 
# Unless required by applicable law or agreed to in writing, software 
# distributed under the License is distributed on an "AS IS" BASIS, 
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 
# See the License for the specific language governing permissions and 
# limitations under the License. 

"""

.. moduleauthor:: John Paulett <john.paulett -at- vanderbilt.edu>
"""

from __future__ import division, with_statement
from hornet.event import GraphsReady, ProcessingStart
from hornet.file import read_csv, write_csv, serialize, deserialize, join
from hornet.network import Node, EdgeDetail, create_directed_graph, add_edge, \
    sort_similarity, rule_generation
from hornet.plugin import Plugin, log, notify_on_finish
from hornet.task import Pool
import logging
import math

logger = logging.getLogger(__name__)
    
def similarity(edge):
    """Compute the similarity of two nodes based upon the cosine 
    similarity. Only works for positive values..
    """
    #return edge[2].size / (math.sqrt(edge[0].size) * 
    #                       math.sqrt(edge[1].size ))
    ## or equivalently (discussion with BAM 01/27/2009)
    return edge[2].size / math.sqrt(edge[0].size * edge[1].size)

def compute_similarities(graph):
    """Takes a full *graph* and computes the similarity for each edge. The
    results of the computation are stored in the 
    :cls:`hornet.network.EdgeDetail`'s *similarity* attribute.
    """
    for edge in graph.edges_iter(data=True):
        edge[2].similarity = similarity(edge)


class IllegalData(Exception): 
    """Exception when the program is given invalid data."""

class BuildRunner(object):
    def __init__(self, data_provider, builder_cls):
        self.data_provider = data_provider
        self.builder_cls = builder_cls
        
    def __call__(self, data):
        builder = self.builder_cls(self.data_provider, data)
        return builder.graph
    
    @classmethod
    def inprocess(cls, data, *args, **kw):
        runner = cls(*args, **kw)
        return [runner(d) for d in data]
    
    @classmethod
    def multiprocess(cls, data, *args, **kw):
        runner = cls(*args, **kw)
        graphs = Pool().map(runner, data) 
        return graphs

class NodeLookup(object):
    """Provides a single instance of a node identified by Node.id, thus 
    preventing multiple instances of the same node from being created. Returns
    an existing node, if applicable, or a new instance of the node, if it 
    has never be created before.  Inserts any new nodes into the instance
    of *graph*.
    """
    def __init__(self, graph):
        self._node_table = {}
        self._graph = graph
        
    def __call__(self, id):
        if id in self._node_table:
            ## a copy already exists
            return self._node_table[id]
        else:
            ## no copy exists, so create a new copy
            node = Node(id)
            self._node_table[id] = node
            ## add the node to the graph reference
            self._graph.add_node(node)
            return node

class PeriodNetworkBuilder(object):
    def __init__(self, data_provider, period):
        self.data_provider = data_provider
        self.period = period

        self.graph = create_directed_graph() 
        self.graph.name = '%s_%s' % period
        self.graph.meta.period = period

        self._node_lookup = NodeLookup(self.graph)
        logger.debug('Starting to build graph %s' % self.graph.name)
        
        self._add_edges()
        logger.debug('Added edges for %s' % self.graph.name)
        
        self._add_node_sizes()
        self._add_attribute_count()
        logger.debug('Added node details for %s' % self.graph.name)
        
        compute_similarities(self.graph)
        logger.debug('Finished adding nodes for %s' % self.graph.name)
        
        rule_generation(self.graph)
        logger.debug('Finished building graph %s' % self.graph.name)
        del self._node_lookup
        
    def _add_edges(self):
        """Add the edges into the graph. If the graph is undirected, both nodes
        are informed of the edge. If the graph is directed, only the first node
        is informed of the edge. If bidirected edges are desired, each edge
        must for a separate row in the data (e.g. A-B, then B-A).
        """
        for row in self.data_provider.get_edge_data(self.period):
            try:
                if row[0] == row[1]:
                    raise IllegalData, 'Duplicate nodes in an edge: %s' % row
                nodeA = self._node_lookup(row[0])
                nodeB = self._node_lookup(row[1])
                try:
                    size = int(row[2])
                except ValueError:
                    raise IllegalData, 'Non-numeric edge size: %s' % row
            except IndexError:
                raise IllegalData, 'Not enough data for creating an edge: %s' % row
            add_edge(self.graph, nodeA, nodeB, size=size)
    
    def _add_node_sizes(self):
        for row in self.data_provider.get_node_size(self.period):
            try:
                node = self._node_lookup(row[0])
                node.size += int(row[1])
            except ValueError:
                raise IllegalData, 'Non-numeric node size: %s' % row
            except IndexError:
                raise IllegalData, 'Not enough data for adding a node: %s' % row
            #FIXME -don't think this is ever used, consider removing
            #if len(row) > 3:
            #    node.attributes = row[3]
                
    def _add_attribute_count(self):
        data = self.data_provider.get_attribute_count(self.period)
        try:
            count = list(data)[0][0]
            self.graph.meta.attributes = int(count)
        except ValueError:
            raise IllegalData, 'Non-numeric attribute count: %s' % data
        except IndexError:
            raise IllegalData, 'Not enough setting the attribute count: %s' % data
    

class AbstractDataProvider(object):
    def get_node_size(self, period):
        """Returns an iterable in the format [node, count <,{attrKey: attrValue}>]
        """
        raise NotImplementedError('Must subclass %s' % self.__class__)
    
    def get_edge_data(self, period):
        """Returns an iterable in the format [node1, node2, intersection_count]
        """
        raise NotImplementedError('Must subclass %s' % self.__class__)
    
    def get_attribute_count(self, period):
        """Returns an integer representing the number of total attributes 
        (things linking the nodes).
        """
        raise NotImplementedError('Must subclass %s' % self.__class__)

class FileDataProvider(AbstractDataProvider):
    def __init__(self, input_dir, 
                 node_fname_fmt='%s_%s_nodes.csv', 
                 edge_fname_fmt='%s_%s_edges.csv', 
                 attrcount_fname_fmt='%s_%s_attrcount.csv'):
        self.input_dir = input_dir
        self.node_fname_fmt = node_fname_fmt
        self.edge_fname_fmt = edge_fname_fmt
        self.attrcount_fname_fmt = attrcount_fname_fmt
    
    def read(self, filename):
        fn = join(self.input_dir, filename)
        return read_csv(fn)
    
    def get_node_size(self, period):
        return self.read(self.node_fname_fmt % period)
    
    def get_edge_data(self, period):
        return self.read(self.edge_fname_fmt % period)
    
    def get_attribute_count(self, period):
        return self.read(self.attrcount_fname_fmt % period)
    

class DatabaseDataProvider(AbstractDataProvider):
    """
    .. warning::
        
        Vulnerable to SQL injections, must treat this code as any standard
        SQL client.
        
    """
    table = 'logs.sl_accesses'
    node_column = 'user_id'
    join_column = 'patient_id'
    time_column = 'access_time'
    date_format = 'MM-DD-YY'

    def format(self, date):
        return date.strftime('%m-%d-%y')

    def get_node_size(self):
        sql = """SELECT %s, COUNT(*)
                 FROM (
                     SELECT DISTINCT %s, %s
                     FROM %s
                     WHERE %s >= TO_DATE('%s','%s')
                     AND %s < TO_DATE('%s','%s'))
                 GROUP BY %s
                 """ % (self.node_column,
                        self.node_column, self.join_column,
                        self.table,
                        self.time_column, self.format(self.start), self.date_format, 
                        self.time_column, self.format(self.end), self.date_format,
                        self.node_column)
        
        return self.db.query(sql)
                        
    def get_edge_data(self):
        sql = """SELECT NODE_A, NODE_B, COUNT(%s) 
                 FROM (
                     SELECT DISTINCT A.%s AS NODE_A, B.%s AS NODE_B, A.%s
                     FROM  %s A, %s B
                     WHERE A.%s >= TO_DATE('%s','%s')
                     AND A.%s < TO_DATE('%s','%s')
                     AND B.%s >= TO_DATE('%s','%s')
                     AND B.%s < TO_DATE('%s','%s')
                     AND A.%s = B.%s
                     AND A.%s != B.%s
                 )
                 GROUP BY NODE_A,NODE_B
                 """ % (self.join_column,
                        self.node_column, self.node_column, self.join_column,
                        self.table, self.table,
                        self.time_column, self.format(self.start), self.date_format, 
                        self.time_column, self.format(self.end), self.date_format,
                        self.time_column, self.format(self.start), self.date_format,
                        self.time_column, self.format(self.end), self.date_format,
                        self.join_column, self.join_column,
                        self.node_column, self.node_column)

        return self.db.query(sql)                

    def get_attribute_count(self):
        sql = """SELECT COUNT(*)
                 FROM (
                     SELECT DISTINCT %s
                     FROM %s 
                     WHERE %s >= TO_DATE('%s','%s')
                     AND %s < TO_DATE('%s','%s') 
                 )
                 """ % (self.join_column,
                        self.table,
                        self.time_column, self.format(self.start), self.date_format, 
                        self.time_column, self.format(self.end), self.date_format)
        return self.db.query(sql)
    
class DatabaseToFile(DatabaseDataProvider):
    """Network builder that converts the data from a 
    :class:`hornet.networkbuilder.DatabaseGraphBuilder` into the format that
    :class:`hornet.networkbuilder.FileGraphBuilder` can accept.
    """
    def write(self, filename, data):
        path = join(self.input_dir, filename)
        write_csv(path, data)
        
    def run(self):
        #self.write('%s_edges.csv' % self.id, self.get_edge_data())
        #self.write('%s_nodes.csv' % self.id, self.get_node_size()) 
        self.write('%s_attrcount.csv' % self.id, self.get_attribute_count())   

class NetworkBuildPlugin(Plugin):
    def __init__(self, data_provider, builder_cls=PeriodNetworkBuilder):
        self.data_provider = data_provider
        self.builder_cls = builder_cls
    
    def accepted_events(self):
        return ProcessingStart
    
    @notify_on_finish
    @log
    def notify(self, event):
        graphs = BuildRunner.inprocess(event.data, 
                                          data_provider=self.data_provider,
                                          builder_cls=self.builder_cls)
        return GraphsReady(graphs)
    
        